iT邦幫忙

2024 iThome 鐵人賽

DAY 12
0
Software Development

螃蟹幼幼班:Rust 入門指南系列 第 12

Day12 - 所有權(一):基礎認識

  • 分享至 

  • xImage
  •  

記憶體管理機制

目前各種程式語言常見的記憶體管理機制主要有兩大類:手動或是垃圾回收機制(Garbage Collector)。

例如 C / C++ 就是用手動的方式分配 (malloc) 和釋放 (free) 記憶體。對工程師來說可以彈性控制的同時,也考驗開發者的實力,忘記釋放記憶體會造成記憶體洩漏(Memory leak),重複釋放可能會把正在其他地方使用的資料弄亂,如果已經釋放了卻又誤用原本的指標可能會引發空指標錯誤(Null Pointer Error),造成程式崩潰。

JavaScript / Go 就是用 Garbage Collector(GC) 的機制,各自有不同的實作。因為記憶體的管理變成像是自動化,開發者可以更專注在程式邏輯,不過普遍來說無法避免影響性能和空間使用效率,在即時性要求高的系統中,如遊戲或金融交易系統,或是在某些需要精細記憶體管理的應用中,比如在嵌入式系統或其他資源受限的環境,會被凸顯出來。

為了解決手動管理記憶體時常見的記憶體洩漏、空指標錯誤等問題,並避免 GC 帶來的性能損耗,Rust 創造了一套全新的記憶體管理機制:所有權(Ownership)。

所有權可以理解為 Rust 用來管理程式記憶體的一系列規則,編譯器會在編譯階段檢查程式碼是否符合這些規則。如果違反規則,程式將無法通過編譯。由於這些檢查僅在編譯時進行,不會影響程式執行時的效能,這也正是 Rust 所強調的零成本抽象

記憶體區域

在介紹所有權之前,需要先簡單知道一下 Stack 和 Heap,它們是不同的記憶體區域提供給程式碼在執行的時候使用,在 Rust 各自有不同的效能和行為,關係到我們選擇使用哪種型別、資料結構等。

Stack:

  1. 一串連續的記憶體以及後進先出的數據結構(LIFO)。
  2. 由於其連續性和後進先出的結構,Stack 上的資料分配和讀取效率非常高。每次新增資料時,直接放在最上面,移除時也只需移除最頂層的資料。
  3. 這種結構決定了 Stack 上的資料必須是固定大小的,因此只有特定型別的資料才能存放在 Stack 上。放的資料必須是固定大小,會限制特定型別的資料才能存在 Stack,例如介紹過的基本型別都有強調他們的大小。
  4. 主要存放呼叫函數時候的參數以及回傳值,這種結構使得函數的執行和返回都非常快速,因為所有的資料都在連續的記憶體中排列整齊。

Heap:

  1. 動態記憶體分配區域,可以根據需要分配任意大小的記憶體,所以放的資料大小可以變動,常用來儲存大小不固定的資料結構如BoxVecString等,但因為管理比較複雜就容易導致記憶體碎片化。
  2. 因為記憶體地址不連續,所以訪問速度會比 Stack 慢,當資料大小變化需要重新分配記憶體時,資料可能會被移動到另一個更大的空間,這樣的操作會導致記憶體碎片化並影響性能,操作上的效能會比 Stack 可以直接放在最頂層差。
  3. 主要用於需要長時間存在或複雜的資料結構,如大型集合或需要在函數之間傳遞的資料。

所以像u32這樣的基本型別,因為大小固定,可以存放在 Stack 上,而像 String 這樣可變大小的資料,則必須存放在 Heap。

對 Stack 和 Heap 有基本認識後,我們繼續回到所有權。

所有權規則

首先像玩桌遊一樣我們先看所有權的規則:

  • Rust 中每個數值都有個擁有者(owner)
  • 同時間只能有一個擁有者。
  • 當擁有者離開作用域時,數值就會被丟棄。

前兩個舉一個例子一起看。
首先看正常的程式碼,宣告一個變數 x 賦值 0,這時候數值是存在 Stack 上,後面再把 x 賦值給 y 的時候其實是複製一份資料,所以後面 y 在做操作的時候就不會影響到 x 原本的數值。

最後把 x 和 y 印出來。

fn main() {
    let x = 0;
    let mut y = x;
    y += 1;
    println!("x: {}, y: {}", x, y);
}

再來我們用另外一個型別 String,之前有提到它是可變的,可以動態地增長或縮減字符串內容。

fn main() {
    let mut s = String::from("hello");
    s.push_str(", world!"); // 將字面值加到字串後面

    println!("{s}"); // hello, world!
}

然後我們做和上面類似的操作,編譯器就報錯了。

fn main() {
    let s = String::from("hello");
    let mut w = s;
    w.push_str(", world!");

    println!("s: {s}, w: {w}");
}
$ cargo run
error[E0382]: borrow of moved value: `s`
 --> src/main.rs:6:18
  |
2 |     let s = String::from("hello");
  |         - move occurs because `s` has type `String`, which does not implement the `Copy` trait
3 |     let mut w = s;
  |                 - value moved here
...
6 |     println!("s: {s}, w: {w}");
  |                  ^^^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let mut w = s.clone();
  |                  ++++++++

For more information about this error, try `rustc --explain E0382`.

訊息量很大,我們先整理幾個關鍵字:move、borrow、clone。

再來解釋一下發生了什麼事。

關鍵在於String 的字串資料不是存在 Stack 而是存在 Heap 上,Stack 上會存元數據(指標、長度、容量),實際的字串 hello 是存在 Heap 上,指標會指向這個位置,如果複製指標資料的話,這幾個指標都是字串資料的擁有者,就違反所有權同時間只能有一個擁有者的規則了,因此,當我們將 String 賦值給另一個變數時,實際會發生所有權的轉移(move)。而 i32 等基本型別則完全存放在 Stack 上,其拷貝開銷極小,Rust 會自動複製它們,每個變數擁有自己一份資料,因此不需要進行所有權轉移。

s 賦值給 w 的時候會把擁有者從 s 換成 w,這句之後 s 就變成是不可用的了,後面 println 的時候它想借用的 s 已經沒有所有權,所以這是一個無效的借用(borrow),Rust 禁止這樣的操作。

修改原本的程式碼把 s 從 println 拿掉,就可以正常執行。

fn main() {
    let s = String::from("hello");
    let mut w = s; // s 所有權給 w 了,後續無法再使用
    w.push_str(", world!");

    println!("w: {w}");
}

錯誤訊息的另外一個提示是可以用 clone。

fn main() {
    let s = String::from("hello");
    let mut w = s.clone();
    w.push_str(", world!");

    println!("s: {s}, w: {w}"); // s: hello, w: hello, world!
}

這樣也可以編譯成功,clone 如它字面意思,包含 Stack 和 Heap 的資料都複製一份獨立的資料,可以想成是 Javascript 的深拷貝(deep copy),因此兩個變數 s 和 w 各自有不同資料的所有權,在最後就都可以合法的 println 出來了。不過這樣做的代價就是根據 Heap 上的資料大小,多複製一份的動作可能會影響效率和效能,尤其資料越大影響越顯著。所以應該只在必要的地方使用,才能發揮出 Rust 的優勢。

最後舉一個例子來觀察所謂 擁有者離開作用域時,數值就會被丟棄這句話。
在 Rust 中特徵(trait) 是一種用來定義共同行為的抽象概念,可以被視為一組方法的集合,定義了一個型別必須實作的行為,但不提供具體的實作細節,目前理解到這樣即可。

首先定義一個結構體(struct),並實作 DropDrop特徵是 Rust 的一個預定義特徵,允許自定義在變數超出作用域時要執行的清理操作,所以我們可以把 dropprintln! 當成是釋放記憶體來觀察。實際 Rust 的資源管理模型和這個特徵無關,這個特徵只是記憶體釋放前的最後一步,所以不會影響到記憶體釋放機制本身。

struct MyStruct {
    name: String,
}

impl Drop for MyStruct {
    fn drop(&mut self) {
        println!("Dropping MyStruct with name: {}", self.name);
    }
}

fn main() {
    let my_struct = MyStruct { // my_struct 在此開始視為有效,my_struct 是擁有者
        name: String::from("Rust"),
    };

    println!("MyStruct created.");
    // 在這裡,my_struct 仍然在作用域內,仍然有效
    println!("MyStruct's name: {}", my_struct.name);
    println!("main scope end");
    // 當它超出作用域時,Drop trait 會被自動調用
} // 這裡 my_struct 超出作用域,會觸發 Drop trait
$ cargo run
MyStruct created.
MyStruct's name: Rust
main scope end
Dropping MyStruct with name: Rust

可以觀察到,drop一直到大括號前的最後一行執行完才執行。

接著我們把上面的程式碼調整一下,把 my_struct 的部分另外用一組 {} 包起來再執行。

fn main() {
    {
        let my_struct = MyStruct {
            name: String::from("Rust"),
        };
    
        println!("MyStruct created.");
        println!("MyStruct's name: {}", my_struct.name);
    } // my_struct 超出作用域
    println!("main scope end");
}
$ cargo run
MyStruct created.
MyStruct's name: Rust
Dropping MyStruct with name: Rust
main scope end

會看到 drop 變成在 main scope end 之前執行了,因為它的作用域在那之前就已經結束了。

如以上例子,當變數超出作用域時,Rust 會自動調用 Drop 來釋放記憶體。這個機制讓我們不需要手動管理記憶體,並能保證不會發生記憶體洩漏。

結語

Rust 的所有權機制不需要手動管理記憶體,同時也避免了垃圾回收的性能負擔。它透過編譯階段的檢查確保記憶體安全,讓開發者既能寫出高效的代碼,也不必擔心記憶體洩漏或其他相關錯誤。另外從上面的規則和例子可以看出 Rust 的一個重要設計決策:永遠不會自動將資料建立「深拷貝」。因此任何自動的拷貝動作都可以被視為是對執行效能影響很小的,這也是 Rust 高效能的基礎之一。

到這邊應該對所有權有基本的認識了,接下來進一步介紹所有權參考與借用(borrow)。


上一篇
Day11 - 函數
下一篇
Day13 - 所有權(二):借用
系列文
螃蟹幼幼班:Rust 入門指南25
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言